--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit dc280c13bd597944fc780f1ce03f3f53efb6c675
Parents : c6d396b
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-13T22:48:12-05:00
feat(reactions): implement emoji reactions for messages, including backend support for sending and processing reactions, frontend display in conversation viewer, and localization updates for multiple languages
Changes
11 files changed, 554 insertions(+), 47 deletions(-)
Diff
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 26219ba2..28985c83 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -69,10 +69,12 @@ from meshchatx.src.backend.lxmf_message_fields import (
LxmfImageField,
)
from meshchatx.src.backend.lxmf_utils import (
+ LXMF_APP_EXTENSIONS_FIELD,
compute_lxmf_conversation_unread_from_latest_row,
convert_db_lxmf_message_to_dict,
convert_lxmf_message_to_dict,
convert_lxmf_state_to_string,
+ lxmf_fields_are_columba_reaction,
)
from meshchatx.src.backend.map_manager import TRANSPARENT_TILE
from meshchatx.src.backend.page_node_manager import PageNodeManager
@@ -8152,6 +8154,42 @@ class ReticulumMeshChat:
status=503,
)
+ @routes.post("/api/v1/lxmf-messages/reactions")
+ async def lxmf_messages_reactions(request):
+ data = await request.json()
+ destination_hash = data.get("destination_hash")
+ target_message_hash = data.get("target_message_hash")
+ emoji = data.get("emoji", "")
+ if not destination_hash or not target_message_hash or not emoji:
+ return web.json_response(
+ {
+ "message": "destination_hash, target_message_hash, and emoji are required",
+ },
+ status=422,
+ )
+ try:
+ lxmf_message = await self.send_reaction(
+ destination_hash=destination_hash,
+ target_message_hash=target_message_hash,
+ emoji=emoji,
+ )
+ return web.json_response(
+ {
+ "lxmf_message": convert_lxmf_message_to_dict(
+ lxmf_message,
+ include_attachments=False,
+ reticulum=self.reticulum,
+ ),
+ },
+ )
+ except Exception as e:
+ return web.json_response(
+ {
+ "message": str(e),
+ },
+ status=503,
+ )
+
# cancel sending lxmf message
@routes.post("/api/v1/lxmf-messages/{hash}/cancel")
async def lxmf_messages_cancel(request):
@@ -12131,9 +12169,22 @@ class ReticulumMeshChat:
message_content = (
lxmf_message.content if hasattr(lxmf_message, "content") else ""
)
+ if isinstance(message_content, bytes):
+ message_content = message_content.decode("utf-8", errors="replace")
+ elif message_content is None:
+ message_content = ""
+ if isinstance(message_title, bytes):
+ message_title = message_title.decode("utf-8", errors="replace")
+ elif message_title is None:
+ message_title = ""
+
+ is_reaction_delivery = lxmf_fields_are_columba_reaction(lxmf_fields)
# check spam keywords
- if self.check_spam_keywords(message_title, message_content):
+ if not is_reaction_delivery and self.check_spam_keywords(
+ message_title,
+ message_content,
+ ):
is_spam = True
print(
f"Marking LXMF message as spam due to keyword match: {source_hash}",
@@ -12339,6 +12390,13 @@ class ReticulumMeshChat:
]
file_attachments_field = LxmfFileAttachmentsField(attachments)
+ app_extensions = None
+ if LXMF_APP_EXTENSIONS_FIELD in lxmf_fields and isinstance(
+ lxmf_fields[LXMF_APP_EXTENSIONS_FIELD],
+ dict,
+ ):
+ app_extensions = lxmf_fields[LXMF_APP_EXTENSIONS_FIELD]
+
# check if this message is for an alias identity (REPLY PATH)
mapping = ctx.database.messages.get_forwarding_mapping(
alias_hash=destination_hash,
@@ -12359,6 +12417,7 @@ class ReticulumMeshChat:
image_field=image_field,
audio_field=audio_field,
file_attachments_field=file_attachments_field,
+ app_extensions=app_extensions,
context=ctx,
),
)
@@ -12401,6 +12460,7 @@ class ReticulumMeshChat:
image_field=image_field,
audio_field=audio_field,
file_attachments_field=file_attachments_field,
+ app_extensions=app_extensions,
context=ctx,
),
)
@@ -12572,6 +12632,7 @@ class ReticulumMeshChat:
sender_identity_hash: str = None,
reply_to_hash: str = None,
reply_quoted_content: str = None,
+ app_extensions: dict = None,
no_display: bool = False,
context=None,
) -> LXMF.LXMessage:
@@ -12579,6 +12640,25 @@ class ReticulumMeshChat:
if not ctx:
raise RuntimeError("No identity context available for sending message")
+ if isinstance(content, bytes):
+ content_str = content.decode("utf-8", errors="replace")
+ else:
+ content_str = content or ""
+ quoted_str = reply_quoted_content or ""
+ is_reaction_only = bool(
+ app_extensions
+ and isinstance(app_extensions, dict)
+ and ("reaction_to" in app_extensions)
+ and not (content_str and content_str.strip())
+ and image_field is None
+ and audio_field is None
+ and file_attachments_field is None
+ and telemetry_data is None
+ and commands is None
+ and reply_to_hash is None
+ and not (quoted_str and quoted_str.strip())
+ )
+
# convert destination hash to bytes
destination_hash_bytes = bytes.fromhex(destination_hash)
@@ -12655,9 +12735,10 @@ class ReticulumMeshChat:
lxmf_message.fields = {}
- lxmf_message.fields[LXMF.FIELD_RENDERER] = LXMF.RENDERER_MARKDOWN
+ if not is_reaction_only:
+ lxmf_message.fields[LXMF.FIELD_RENDERER] = LXMF.RENDERER_MARKDOWN
- if self._is_contact(destination_hash, context=ctx):
+ if self._is_contact(destination_hash, context=ctx) and not is_reaction_only:
lxmf_message.include_ticket = True
# add file attachments field
@@ -12702,9 +12783,12 @@ class ReticulumMeshChat:
if reply_quoted_content is not None and reply_quoted_content:
lxmf_message.fields[0x31] = reply_quoted_content.encode("utf-8")
+ if app_extensions is not None:
+ lxmf_message.fields[LXMF_APP_EXTENSIONS_FIELD] = app_extensions
+
# add icon appearance if configured and not already sent to this destination
current_icon_hash = self.get_current_icon_hash()
- if current_icon_hash is not None:
+ if current_icon_hash is not None and not is_reaction_only:
last_sent_icon_hash = self.database.misc.get_last_sent_icon_hash(
destination_hash,
)
@@ -12783,6 +12867,30 @@ class ReticulumMeshChat:
return lxmf_message
+ async def send_reaction(
+ self,
+ destination_hash: str,
+ target_message_hash: str,
+ emoji: str,
+ context=None,
+ ) -> LXMF.LXMessage:
+ ctx = context or self.current_context
+ if not ctx:
+ raise RuntimeError("No identity context available for sending reaction")
+ sender_hex = ctx.identity.hash.hex()
+ app_extensions = {
+ "reaction_to": target_message_hash,
+ "emoji": emoji,
+ "sender": sender_hex,
+ }
+ return await self.send_message(
+ destination_hash=destination_hash,
+ content="",
+ delivery_method="opportunistic",
+ app_extensions=app_extensions,
+ context=context,
+ )
+
# get hash of current icon appearance configuration
def get_current_icon_hash(self, context=None):
ctx = context or self.current_context
diff --git a/meshchatx/src/backend/lxmf_utils.py b/meshchatx/src/backend/lxmf_utils.py
index b1b2352d..3f337f80 100644
--- a/meshchatx/src/backend/lxmf_utils.py
+++ b/meshchatx/src/backend/lxmf_utils.py
@@ -5,6 +5,16 @@ import LXMF
from meshchatx.src.backend.telemetry_utils import Telemeter
+# Columba-compatible app extensions (emoji reactions, reply metadata, etc.)
+LXMF_APP_EXTENSIONS_FIELD = 16
+
+
+def lxmf_fields_are_columba_reaction(lxmf_fields: dict) -> bool:
+ if not isinstance(lxmf_fields, dict):
+ return False
+ val = lxmf_fields.get(LXMF_APP_EXTENSIONS_FIELD)
+ return isinstance(val, dict) and "reaction_to" in val
+
def convert_lxmf_message_to_dict(
lxmf_message: LXMF.LXMessage,
@@ -14,6 +24,10 @@ def convert_lxmf_message_to_dict(
# handle fields
fields = {}
message_fields = lxmf_message.get_fields()
+ is_reaction = False
+ reaction_to = None
+ reaction_emoji = None
+ reaction_sender = None
for field_type in message_fields:
value = message_fields[field_type]
@@ -123,6 +137,14 @@ def convert_lxmf_message_to_dict(
else value
)
+ if field_type == LXMF_APP_EXTENSIONS_FIELD and isinstance(value, dict):
+ fields["app_extensions"] = dict(value)
+ if "reaction_to" in value:
+ is_reaction = True
+ reaction_to = value.get("reaction_to") or ""
+ reaction_emoji = value.get("emoji") or ""
+ reaction_sender = value.get("sender") or ""
+
# convert 0.0-1.0 progress to 0.00-100 percentage
progress_percentage = round(lxmf_message.progress * 100, 2)
@@ -161,7 +183,7 @@ def convert_lxmf_message_to_dict(
if match:
reply_to_hash = match.group(1)
- return {
+ out = {
"hash": lxmf_message.hash.hex(),
"source_hash": lxmf_message.source_hash.hex(),
"destination_hash": lxmf_message.destination_hash.hex(),
@@ -185,7 +207,13 @@ def convert_lxmf_message_to_dict(
"snr": snr,
"quality": quality,
"reply_to_hash": reply_to_hash,
+ "is_reaction": is_reaction,
}
+ if is_reaction:
+ out["reaction_to"] = reaction_to
+ out["reaction_emoji"] = reaction_emoji
+ out["reaction_sender"] = reaction_sender
+ return out
def convert_lxmf_state_to_string(lxmf_message: LXMF.LXMessage):
@@ -239,6 +267,17 @@ def convert_db_lxmf_message_to_dict(
if not isinstance(fields, dict):
fields = {}
+ is_reaction = False
+ reaction_to = None
+ reaction_emoji = None
+ reaction_sender = None
+ app = fields.get("app_extensions")
+ if isinstance(app, dict) and "reaction_to" in app:
+ is_reaction = True
+ reaction_to = app.get("reaction_to") or ""
+ reaction_emoji = app.get("emoji") or ""
+ reaction_sender = app.get("sender") or ""
+
# normalize commands if present
if "commands" in fields:
cmds = fields["commands"]
@@ -329,7 +368,7 @@ def convert_db_lxmf_message_to_dict(
if updated_at and "+" not in updated_at and "Z" not in updated_at:
updated_at += "Z"
- return {
+ out = {
"id": db_lxmf_message["id"],
"hash": db_lxmf_message["hash"],
"source_hash": db_lxmf_message["source_hash"],
@@ -352,7 +391,13 @@ def convert_db_lxmf_message_to_dict(
"attachments_stripped": bool(db_lxmf_message.get("attachments_stripped", 0)),
"created_at": created_at,
"updated_at": updated_at,
+ "is_reaction": is_reaction,
}
+ if is_reaction:
+ out["reaction_to"] = reaction_to
+ out["reaction_emoji"] = reaction_emoji
+ out["reaction_sender"] = reaction_sender
+ return out
def compute_lxmf_conversation_unread_from_latest_row(row):
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index b85e90a2..26a28f60 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -1313,6 +1313,18 @@
</div>
</div>
+ <div
+ v-if="chatItem.lxmf_message.reactions?.length"
+ class="mt-1 flex w-full flex-wrap justify-end gap-0.5 px-0.5"
+ >
+ <span
+ v-for="(r, ridx) in chatItem.lxmf_message.reactions"
+ :key="r.reactionHash || ridx"
+ class="inline-flex min-h-[1.35rem] min-w-[1.35rem] cursor-default select-none items-center justify-center rounded-full border border-gray-200/90 bg-white px-1.5 py-0.5 text-sm leading-none shadow-sm dark:border-zinc-600/90 dark:bg-zinc-900"
+ :title="reactionReactorLabel(r.sender)"
+ >{{ r.emoji }}</span>
+ </div>
+
<!-- expanded message details -->
<div
v-if="expandedMessageInfo === chatItem.lxmf_message.hash"
@@ -1822,82 +1834,94 @@
<!-- Message Context Menu (Teleport to body to avoid overflow clipping) -->
<Teleport to="body">
- <div
- v-if="messageContextMenu.show"
+ <ContextMenuPanel
+ :show="messageContextMenu.show"
+ :x="messageContextMenu.x"
+ :y="messageContextMenu.y"
+ panel-class="z-[200]"
v-click-outside="{
handler: () => {
if (!messageContextMenu.justOpened) messageContextMenu.show = false;
},
capture: true,
}"
- class="fixed z-[200] min-w-[180px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
- :style="{ top: messageContextMenu.y + 'px', left: messageContextMenu.x + 'px' }"
>
- <button
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
- @click="replyToMessage(messageContextMenu.chatItem)"
- >
+ <ContextMenuItem @click="replyToMessage(messageContextMenu.chatItem)">
<MaterialDesignIcon icon-name="reply" class="size-4 text-indigo-500" />
- <span class="font-medium">Reply</span>
- </button>
- <button
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
+ Reply
+ </ContextMenuItem>
+ <div
+ v-if="messageContextMenu.chatItem && !messageContextMenu.chatItem.lxmf_message?.is_reaction"
+ class="px-3 py-2 border-t border-gray-100 dark:border-zinc-700"
+ >
+ <div
+ class="text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:text-zinc-400 mb-1.5"
+ >
+ {{ $t("messages.react") }}
+ </div>
+ <div class="flex flex-wrap gap-1">
+ <button
+ v-for="(emo, emi) in columbaReactionEmojis"
+ :key="emi"
+ type="button"
+ class="text-lg leading-none px-1.5 py-0.5 rounded-lg hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
+ :title="emo"
+ @click="sendReactionEmojiFromMenu(messageContextMenu.chatItem, emo)"
+ >
+ {{ emo }}
+ </button>
+ </div>
+ </div>
+ <ContextMenuItem
@click="
showRawMessage(messageContextMenu.chatItem);
messageContextMenu.show = false;
"
>
<MaterialDesignIcon icon-name="code-json" class="size-4 text-gray-400" />
- <span class="font-medium">View Raw LXM</span>
- </button>
- <button
+ View Raw LXM
+ </ContextMenuItem>
+ <ContextMenuItem
v-if="messageContextMenu.chatItem?.lxmf_message?.fields?.image"
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
@click="saveMessageImageToStickers(messageContextMenu.chatItem)"
>
<MaterialDesignIcon icon-name="bookmark-plus-outline" class="size-4 text-teal-500" />
- <span class="font-medium">{{ $t("stickers.save_to_library") }}</span>
- </button>
- <button
+ {{ $t("stickers.save_to_library") }}
+ </ContextMenuItem>
+ <ContextMenuItem
v-if="
messageContextMenu.chatItem?.is_outbound &&
['failed', 'cancelled'].includes(messageContextMenu.chatItem?.lxmf_message?.state)
"
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-all active:scale-95"
+ item-class="text-amber-600 dark:text-amber-400"
@click="
retrySendingMessage(messageContextMenu.chatItem);
messageContextMenu.show = false;
"
>
<MaterialDesignIcon icon-name="refresh" class="size-4" />
- <span class="font-medium">Retry</span>
- </button>
- <button
+ Retry
+ </ContextMenuItem>
+ <ContextMenuItem
v-if="isSelectedPeerBlocked && selectedPeer"
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 transition-all active:scale-95"
+ item-class="text-emerald-600 dark:text-emerald-400"
@click="liftBanishmentFromMessageMenu"
>
<MaterialDesignIcon icon-name="check-circle" class="size-4" />
- <span class="font-medium">{{ $t("banishment.lift_banishment") }}</span>
- </button>
- <div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
- <button
- type="button"
- class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-900/20 transition-all active:scale-95"
+ {{ $t("banishment.lift_banishment") }}
+ </ContextMenuItem>
+ <ContextMenuDivider />
+ <ContextMenuItem
+ item-class="text-red-600 dark:text-red-400"
@click="
deleteChatItem(messageContextMenu.chatItem);
messageContextMenu.show = false;
"
>
<MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
- <span class="font-medium">Delete</span>
- </button>
- </div>
+ Delete
+ </ContextMenuItem>
+ </ContextMenuPanel>
</Teleport>
</div>
@@ -2391,6 +2415,9 @@ import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
import SendMessageButton from "./SendMessageButton.vue";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+import ContextMenuDivider from "../contextmenu/ContextMenuDivider.vue";
+import ContextMenuItem from "../contextmenu/ContextMenuItem.vue";
+import ContextMenuPanel from "../contextmenu/ContextMenuPanel.vue";
import ConversationDropDownMenu from "./ConversationDropDownMenu.vue";
import AddImageButton from "./AddImageButton.vue";
import AudioWaveformPlayer from "./AudioWaveformPlayer.vue";
@@ -2401,6 +2428,7 @@ import ToastUtils from "../../js/ToastUtils";
import PaperMessageModal from "./PaperMessageModal.vue";
import GlobalState from "../../js/GlobalState";
import MarkdownRenderer from "../../js/MarkdownRenderer";
+import { COLUMBA_REACTION_EMOJIS, mergeLxmfReactionRowsIntoMessages } from "../../js/lxmfReactions";
import { createOutboundQueue } from "../../js/outboundSendQueue";
import emojiPickerEnDataUrl from "emoji-picker-element-data/en/emojibase/data.json?url";
import "emoji-picker-element";
@@ -2410,6 +2438,9 @@ export default {
components: {
IconButton,
AddImageButton,
+ ContextMenuDivider,
+ ContextMenuItem,
+ ContextMenuPanel,
ConversationDropDownMenu,
MaterialDesignIcon,
SendMessageButton,
@@ -2519,6 +2550,7 @@ export default {
chatItem: null,
justOpened: false,
},
+ columbaReactionEmojis: COLUMBA_REACTION_EMOJIS,
userStickers: [],
isStickerPickerOpen: false,
emojiStickerTab: "emoji",
@@ -2714,6 +2746,10 @@ export default {
return false;
}
+ if (chatItem.lxmf_message.is_reaction) {
+ return false;
+ }
+
return true;
}
@@ -3224,7 +3260,7 @@ export default {
// convert lxmf messages to chat items
const chatItems = [];
- const lxmfMessages = response.data.lxmf_messages;
+ const lxmfMessages = mergeLxmfReactionRowsIntoMessages(response.data.lxmf_messages);
for (const lxmfMessage of lxmfMessages) {
chatItems.push({
type: "lxmf_message",
@@ -3484,6 +3520,11 @@ export default {
return;
}
+ if (lxmfMessage.is_reaction && lxmfMessage.reaction_to) {
+ this.applyIncomingReaction(lxmfMessage);
+ return;
+ }
+
this.chatItems.push({
type: "lxmf_message",
is_outbound: false,
@@ -3795,6 +3836,81 @@ export default {
const item = this.chatItems.find((i) => i.lxmf_message?.hash === hash);
return item ? item.lxmf_message : null;
},
+ reactionReactorLabel(senderHex) {
+ if (!senderHex || typeof senderHex !== "string") {
+ return "";
+ }
+ const hex = senderHex.toLowerCase();
+ if (this.myLxmfAddressHash && hex === String(this.myLxmfAddressHash).toLowerCase()) {
+ return this.$t("messages.reaction_you");
+ }
+ if (
+ this.selectedPeer?.destination_hash &&
+ hex === String(this.selectedPeer.destination_hash).toLowerCase()
+ ) {
+ return (
+ this.selectedPeer.custom_display_name ??
+ this.selectedPeer.display_name ??
+ this.formatDestinationHash(hex)
+ );
+ }
+ const conv = this.conversations.find(
+ (c) => c.destination_hash && String(c.destination_hash).toLowerCase() === hex,
+ );
+ if (conv) {
+ return conv.custom_display_name ?? conv.display_name ?? this.formatDestinationHash(hex);
+ }
+ return this.formatDestinationHash(hex);
+ },
+ applyIncomingReaction(lxmfMessage) {
+ const target = this.chatItems.find((i) => i.lxmf_message?.hash === lxmfMessage.reaction_to);
+ if (!target || !target.lxmf_message) {
+ return;
+ }
+ if (!target.lxmf_message.reactions) {
+ target.lxmf_message.reactions = [];
+ }
+ const sender = lxmfMessage.reaction_sender || lxmfMessage.source_hash || "";
+ const emoji = lxmfMessage.reaction_emoji || "";
+ const dup = target.lxmf_message.reactions.some((r) => r.sender === sender && r.emoji === emoji);
+ if (dup) {
+ return;
+ }
+ target.lxmf_message.reactions.push({
+ emoji,
+ sender,
+ reactionHash: lxmfMessage.hash,
+ });
+ },
+ async sendReactionEmojiFromMenu(chatItem, emoji) {
+ this.messageContextMenu.show = false;
+ const hash = chatItem.lxmf_message?.hash;
+ if (!hash || !this.selectedPeer?.destination_hash) {
+ return;
+ }
+ try {
+ await window.api.post("/api/v1/lxmf-messages/reactions", {
+ destination_hash: this.selectedPeer.destination_hash,
+ target_message_hash: hash,
+ emoji,
+ });
+ const sender = this.myLxmfAddressHash;
+ if (!chatItem.lxmf_message.reactions) {
+ chatItem.lxmf_message.reactions = [];
+ }
+ const dup = chatItem.lxmf_message.reactions.some((r) => r.sender === sender && r.emoji === emoji);
+ if (!dup) {
+ chatItem.lxmf_message.reactions.push({
+ emoji,
+ sender,
+ reactionHash: null,
+ });
+ }
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error(this.$t("messages.reaction_send_failed"));
+ }
+ },
onMessageContextMenu(event, chatItem) {
this.messageContextMenu.chatItem = chatItem;
this.messageContextMenu.justOpened = true;
diff --git a/meshchatx/src/frontend/js/lxmfReactions.js b/meshchatx/src/frontend/js/lxmfReactions.js
new file mode 100644
index 00000000..f99620f7
--- /dev/null
+++ b/meshchatx/src/frontend/js/lxmfReactions.js
@@ -0,0 +1,52 @@
+/**
+ * Columba-compatible LXMF field-16 reactions: merge reaction rows onto parent messages.
+ */
+
+export const COLUMBA_REACTION_EMOJIS = [
+ "\u{1F44D}",
+ "\u2764\uFE0F",
+ "\u{1F602}",
+ "\u{1F62E}",
+ "\u{1F622}",
+ "\u{1F621}",
+];
+
+export function mergeLxmfReactionRowsIntoMessages(messages) {
+ if (!Array.isArray(messages) || messages.length === 0) {
+ return messages;
+ }
+ const parents = [];
+ const reactions = [];
+ for (const m of messages) {
+ if (!m) {
+ continue;
+ }
+ if (m.is_reaction) {
+ reactions.push(m);
+ } else {
+ parents.push({ ...m, reactions: [] });
+ }
+ }
+ const byHash = new Map(parents.map((p) => [p.hash, p]));
+ for (const r of reactions) {
+ const targetId = r.reaction_to;
+ if (!targetId) {
+ continue;
+ }
+ const parent = byHash.get(targetId);
+ if (!parent) {
+ continue;
+ }
+ const sender = r.reaction_sender || r.source_hash || "";
+ const emoji = r.reaction_emoji || "";
+ const dup = parent.reactions.some((x) => x.sender === sender && x.emoji === emoji);
+ if (!dup) {
+ parent.reactions.push({
+ emoji,
+ sender,
+ reactionHash: r.hash,
+ });
+ }
+ }
+ return parents;
+}
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index fa6444d9..2d28a3ff 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -824,6 +824,9 @@
"reply": "Antworten",
"replying_to": "Antwort an",
"message_actions": "Nachrichtenaktionen",
+ "react": "Reagieren",
+ "reaction_you": "Du",
+ "reaction_send_failed": "Reaktion konnte nicht gesendet werden",
"message_not_found_in_cache": "Nachricht nicht im Cache gefunden"
},
"nomadnet": {
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 6b5810c0..f64e6f44 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -774,6 +774,9 @@
"reply": "Reply",
"replying_to": "Replying to",
"message_actions": "Message actions",
+ "react": "React",
+ "reaction_you": "You",
+ "reaction_send_failed": "Could not send reaction",
"message_not_found_in_cache": "Message not found in cache"
},
"settings": {
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index 09bb16ec..7615730e 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -824,6 +824,9 @@
"reply": "Rispondi",
"replying_to": "In risposta a",
"message_actions": "Azioni messaggio",
+ "react": "Reagisci",
+ "reaction_you": "Tu",
+ "reaction_send_failed": "Impossibile inviare la reazione",
"message_not_found_in_cache": "Messaggio non trovato nella cache"
},
"settings": {
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 78f17ee8..4cc703ac 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -824,6 +824,9 @@
"reply": "Ответить",
"replying_to": "Ответ на",
"message_actions": "Действия с сообщением",
+ "react": "Реакции",
+ "reaction_you": "Вы",
+ "reaction_send_failed": "Не удалось отправить реакцию",
"message_not_found_in_cache": "Сообщение не найдено в кэше"
},
"nomadnet": {
diff --git a/tests/backend/test_lxmf_reactions.py b/tests/backend/test_lxmf_reactions.py
new file mode 100644
index 00000000..4e56efa5
--- /dev/null
+++ b/tests/backend/test_lxmf_reactions.py
@@ -0,0 +1,119 @@
+import json
+from unittest.mock import MagicMock
+
+import LXMF
+
+from meshchatx.src.backend.lxmf_utils import (
+ LXMF_APP_EXTENSIONS_FIELD,
+ convert_db_lxmf_message_to_dict,
+ convert_lxmf_message_to_dict,
+ lxmf_fields_are_columba_reaction,
+)
+
+
+def test_lxmf_fields_are_columba_reaction():
+ assert lxmf_fields_are_columba_reaction({}) is False
+ assert lxmf_fields_are_columba_reaction({16: {"reaction_to": "abc"}}) is True
+ assert lxmf_fields_are_columba_reaction({16: {"reply_to": "x"}}) is False
+
+
+def test_convert_lxmf_message_to_dict_reaction_field_16():
+ mock_msg = MagicMock(spec=LXMF.LXMessage)
+ mock_msg.hash = b"h" * 8
+ mock_msg.source_hash = b"s" * 8
+ mock_msg.destination_hash = b"d" * 8
+ mock_msg.incoming = True
+ mock_msg.state = LXMF.LXMessage.SENT
+ mock_msg.progress = 1.0
+ mock_msg.method = LXMF.LXMessage.OPPORTUNISTIC
+ mock_msg.delivery_attempts = 0
+ mock_msg.title = b""
+ mock_msg.content = b""
+ mock_msg.timestamp = 1000
+ mock_msg.rssi = None
+ mock_msg.snr = None
+ mock_msg.q = None
+ target = "a" * 32
+ mock_msg.get_fields.return_value = {
+ LXMF_APP_EXTENSIONS_FIELD: {
+ "reaction_to": target,
+ "emoji": "\U0001f44d",
+ "sender": "f" * 32,
+ }
+ }
+
+ out = convert_lxmf_message_to_dict(mock_msg, include_attachments=False)
+
+ assert out["is_reaction"] is True
+ assert out["reaction_to"] == target
+ assert out["reaction_emoji"] == "\U0001f44d"
+ assert out["reaction_sender"] == "f" * 32
+ assert "app_extensions" in out["fields"]
+ assert out["fields"]["app_extensions"]["reaction_to"] == target
+
+
+def test_convert_db_lxmf_message_to_dict_reaction():
+ target = "aa" * 16
+ fields_obj = {
+ "app_extensions": {
+ "reaction_to": target,
+ "emoji": "\u2764\ufe0f",
+ "sender": "11" * 16,
+ }
+ }
+ row = {
+ "id": 1,
+ "hash": "ab" * 16,
+ "source_hash": "cd" * 16,
+ "destination_hash": "ef" * 16,
+ "is_incoming": 1,
+ "state": "delivered",
+ "progress": 100.0,
+ "method": "opportunistic",
+ "delivery_attempts": 0,
+ "next_delivery_attempt_at": None,
+ "title": "",
+ "content": "",
+ "fields": json.dumps(fields_obj),
+ "timestamp": 1.0,
+ "rssi": None,
+ "snr": None,
+ "quality": None,
+ "is_spam": 0,
+ "reply_to_hash": None,
+ "attachments_stripped": 0,
+ "created_at": "2020-01-01T00:00:00",
+ "updated_at": "2020-01-01T00:00:00",
+ }
+
+ out = convert_db_lxmf_message_to_dict(row, include_attachments=False)
+ assert out["is_reaction"] is True
+ assert out["reaction_to"] == target
+ assert out["reaction_emoji"] == "\u2764\ufe0f"
+ assert out["reaction_sender"] == "11" * 16
+
+
+def test_convert_lxmf_message_to_dict_non_reaction_field_16_reply_to():
+ mock_msg = MagicMock(spec=LXMF.LXMessage)
+ mock_msg.hash = b"h" * 8
+ mock_msg.source_hash = b"s" * 8
+ mock_msg.destination_hash = b"d" * 8
+ mock_msg.incoming = True
+ mock_msg.state = LXMF.LXMessage.SENT
+ mock_msg.progress = 1.0
+ mock_msg.method = LXMF.LXMessage.DIRECT
+ mock_msg.delivery_attempts = 0
+ mock_msg.title = b""
+ mock_msg.content = b"hi"
+ mock_msg.timestamp = 1000
+ mock_msg.rssi = None
+ mock_msg.snr = None
+ mock_msg.q = None
+ mock_msg.get_fields.return_value = {
+ LXMF_APP_EXTENSIONS_FIELD: {"reply_to": "someid", "pending": True},
+ }
+
+ out = convert_lxmf_message_to_dict(mock_msg, include_attachments=False)
+ assert out["is_reaction"] is False
+ assert "reaction_to" not in out
+ assert "app_extensions" in out["fields"]
diff --git a/tests/frontend/ConversationViewerButtons.test.js b/tests/frontend/ConversationViewerButtons.test.js
index 1f2ddcaa..5cad0593 100644
--- a/tests/frontend/ConversationViewerButtons.test.js
+++ b/tests/frontend/ConversationViewerButtons.test.js
@@ -171,7 +171,7 @@ describe("ConversationViewer.vue button interactions", () => {
expect(wrapper.vm.messageContextMenu.show).toBe(true);
- const menuEl = Array.from(document.body.querySelectorAll(".fixed")).find(
+ const menuEl = Array.from(document.body.querySelectorAll(".context-menu-panel")).find(
(el) => el.textContent?.includes("Reply") && el.textContent?.includes("Delete")
);
expect(menuEl).toBeTruthy();
@@ -201,7 +201,7 @@ describe("ConversationViewer.vue button interactions", () => {
wrapper.vm.messageContextMenu.show = true;
await wrapper.vm.$nextTick();
- const menuEl = Array.from(document.body.querySelectorAll(".fixed")).find(
+ const menuEl = Array.from(document.body.querySelectorAll(".context-menu-panel")).find(
(el) => el.textContent?.includes("Reply") && el.textContent?.includes("Delete")
);
const deleteBtn = menuEl
diff --git a/tests/frontend/lxmfReactions.test.js b/tests/frontend/lxmfReactions.test.js
new file mode 100644
index 00000000..6b3a8157
--- /dev/null
+++ b/tests/frontend/lxmfReactions.test.js
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import { mergeLxmfReactionRowsIntoMessages } from "../../meshchatx/src/frontend/js/lxmfReactions";
+
+describe("mergeLxmfReactionRowsIntoMessages", () => {
+ it("merges reaction rows onto parents and drops reaction-only rows", () => {
+ const parentHash = "a".repeat(32);
+ const incoming = [
+ {
+ hash: parentHash,
+ source_hash: "b".repeat(32),
+ content: "hello",
+ is_reaction: false,
+ },
+ {
+ hash: "c".repeat(32),
+ source_hash: "d".repeat(32),
+ content: "",
+ is_reaction: true,
+ reaction_to: parentHash,
+ reaction_emoji: "\u{1F44D}",
+ reaction_sender: "e".repeat(32),
+ },
+ ];
+ const out = mergeLxmfReactionRowsIntoMessages(incoming);
+ expect(out).toHaveLength(1);
+ expect(out[0].hash).toBe(parentHash);
+ expect(out[0].reactions).toHaveLength(1);
+ expect(out[0].reactions[0].emoji).toBe("\u{1F44D}");
+ expect(out[0].reactions[0].sender).toBe("e".repeat(32));
+ });
+
+ it("dedupes same sender and emoji", () => {
+ const parentHash = "a".repeat(32);
+ const sender = "e".repeat(32);
+ const incoming = [
+ { hash: parentHash, content: "x", is_reaction: false },
+ {
+ hash: "r1",
+ is_reaction: true,
+ reaction_to: parentHash,
+ reaction_emoji: "\u{1F44D}",
+ reaction_sender: sender,
+ },
+ {
+ hash: "r2",
+ is_reaction: true,
+ reaction_to: parentHash,
+ reaction_emoji: "\u{1F44D}",
+ reaction_sender: sender,
+ },
+ ];
+ const out = mergeLxmfReactionRowsIntoMessages(incoming);
+ expect(out[0].reactions).toHaveLength(1);
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────